#!/bin/bash

# Copyright (c) 2014 Spotify AB.
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

# This script attempts to garbage collect docker containers and images.
# Containers that exited more than an hour ago are removed.
# Images that have existed more than an hour and are not in use by any
# containers are removed.

# Note: Although docker normally prevents removal of images that are in use by
#       containers, we take extra care to not remove any image tags (e.g.
#       ubuntu:14.04, busybox, etc) that are used by containers. A naive
#       "docker rmi `docker images -q`" will leave images stripped of all tags,
#       forcing users to re-pull the repositories even though the images
#       themselves are still on disk.

# Note: State is stored in $STATE_DIR, defaulting to /var/lib/docker-gc

# The script can send log messages to syslog regarding which images and
# containers were removed. To enable logging to syslog, set LOG_TO_SYSLOG=1.
# When disabled, this script will instead log to standard out. When syslog is
# enabled, the syslog facility and logger can be configured with
# $SYSLOG_FACILITY and $SYSLOG_LEVEL respectively.

set -o nounset
set -o errexit

GRACE_PERIOD_SECONDS=${GRACE_PERIOD_SECONDS:=3600}
MINIMUM_IMAGES_TO_SAVE=${MINIMUM_IMAGES_TO_SAVE:=0}
STATE_DIR=${STATE_DIR:=/var/lib/docker-gc}
REMOVE_ASSOCIATED_VOLUME=${REMOVE_ASSOCIATED_VOLUME=1}
FORCE_CONTAINER_REMOVAL=${FORCE_CONTAINER_REMOVAL:=0}
FORCE_IMAGE_REMOVAL=${FORCE_IMAGE_REMOVAL:=0}
DOCKER=${DOCKER:=docker}
PID_DIR=${PID_DIR:=/var/run}
LOG_TO_SYSLOG=${LOG_TO_SYSLOG:=0}
SYSLOG_FACILITY=${SYSLOG_FACILITY:=user}
SYSLOG_LEVEL=${SYSLOG_LEVEL:=info}
SYSLOG_TAG=${SYSLOG_TAG:=docker-gc}
DRY_RUN=${DRY_RUN:=0}
EXCLUDE_DEAD=${EXCLUDE_DEAD:=0}
REMOVE_VOLUMES=${REMOVE_VOLUMES:=0}
EXCLUDE_VOLUMES_IDS_FILE=${EXCLUDE_VOLUMES_IDS_FILE:=/etc/docker-gc-exclude-volumes}
VOLUME_DELETE_ONLY_DRIVER=${VOLUME_DELETE_ONLY_DRIVER:=local}
PIDFILE=$PID_DIR/dockergc

exec 3>>$PIDFILE
if ! flock -x -n 3; then
  echo "[$(date)] : docker-gc : Process is already running"
  exit 1
fi

trap "rm -f -- '$PIDFILE'" EXIT

echo $$ > $PIDFILE

EXCLUDE_FROM_GC=${EXCLUDE_FROM_GC:=/etc/docker-gc-exclude}
if [ ! -f "$EXCLUDE_FROM_GC" ]; then
  EXCLUDE_FROM_GC=/dev/null
fi

EXCLUDE_CONTAINERS_FROM_GC=${EXCLUDE_CONTAINERS_FROM_GC:=/etc/docker-gc-exclude-containers}
if [ ! -f "$EXCLUDE_CONTAINERS_FROM_GC" ]; then
  EXCLUDE_CONTAINERS_FROM_GC=/dev/null
fi

EXCLUDE_IDS_FILE="exclude_ids"
EXCLUDE_CONTAINER_IDS_FILE="exclude_container_ids"

function date_parse() {
  if date --utc >/dev/null 2>&1; then
    # GNU/date
    date -u --date "${1}" "+%s"
  else
    # BSD/date
    date -j -u -f "%F %T" "${1}" "+%s"
  fi
}

# Elapsed time since a docker timestamp, in seconds
function elapsed_time() {
  # Docker 1.5.0 datetime format is 2015-07-03T02:39:00.390284991
  # Docker 1.7.0 datetime format is 2015-07-03 02:39:00.390284991 +0000 UTC
  utcnow=$(date -u "+%s")
  replace_q="${1#\"}"
  without_ms="${replace_q:0:19}"
  replace_t="${without_ms/T/ }"
  epoch=$(date_parse "${replace_t}")
  echo $(($utcnow - $epoch))
}

function compute_exclude_ids() {
  # Find images that match patterns in the EXCLUDE_FROM_GC file and put their
  # id prefixes into $EXCLUDE_IDS_FILE, prefixed with ^

  PROCESSED_EXCLUDES="processed_excludes.tmp"
  # Take each line and put a space at the beginning and end, so when we
  # grep for them below, it will effectively be: "match either repo:tag
  # or imageid".  Also delete blank lines or lines that only contain
  # whitespace
  sed 's/^\(.*\)$/ \1 /' $EXCLUDE_FROM_GC | sed '/^ *$/d' > $PROCESSED_EXCLUDES
  # The following looks a bit of a mess, but here's what it does:
  # 1. Get images
  # 2. Skip header line
  # 3. Turn columnar display of 'REPO TAG IMAGEID ....' to 'REPO:TAG IMAGEID'
  # 4. find lines that contain things mentioned in PROCESSED_EXCLUDES
  # 5. Grab the image id from the line
  # 6. Prepend ^ to the beginning of each line

  # What this does is make grep patterns to match image ids mentioned by
  # either repo:tag or image id for later greppage
  $DOCKER images \
    | tail -n+2 \
    | sed 's/^\([^ ]*\) *\([^ ]*\) *\([^ ]*\).*/ \1:\2 \3 /' \
    | grep -f $PROCESSED_EXCLUDES 2>/dev/null \
    | cut -d' ' -f3 \
    | sed 's/^/^(sha256:)?/' > $EXCLUDE_IDS_FILE
}

function compute_exclude_container_ids() {
  # Find containers matching to patterns listed in EXCLUDE_CONTAINERS_FROM_GC file
  # Implode their values with a \| separator on a single line
  PROCESSED_EXCLUDES=`xargs < $EXCLUDE_CONTAINERS_FROM_GC \
    | sed -e 's/ /\|/g'`
  # The empty string would match everything
  if [ "$PROCESSED_EXCLUDES" = "" ]; then
    touch $EXCLUDE_CONTAINER_IDS_FILE
    return
  fi
  # Find all docker images
  # Filter out with matching names
  # and put them to $EXCLUDE_CONTAINER_IDS_FILE
  $DOCKER ps -a \
    | grep -E "$PROCESSED_EXCLUDES" \
    | awk '{ print $1 }' \
    | tr -s " " "\012" \
    | sort -u > $EXCLUDE_CONTAINER_IDS_FILE
}

function log() {
  msg=$1
  if [[ $LOG_TO_SYSLOG -gt 0 ]]; then
    logger -i -t "$SYSLOG_TAG" -p "$SYSLOG_FACILITY.$SYSLOG_LEVEL" "$msg"
  else
    echo "[$(date +'%Y-%m-%dT%H:%M:%S')] [INFO] : $msg"
  fi
}

function container_log() {
  prefix=$1
  filename=$2

  while IFS='' read -r containerid
  do
    log "$prefix $containerid $(${DOCKER} inspect -f {{.Name}} $containerid)"
  done < "$filename"
}

function image_log() {
  prefix=$1
  filename=$2

  while IFS='' read -r imageid
  do
    log "$prefix $imageid $(${DOCKER} inspect -f {{.RepoTags}} $imageid)"
  done < "$filename"
}

function volumes_log() {
  prefix=$1
  filename=$2

  while IFS='' read -r volumeid
  do
    log "$prefix $volumeid"
  done < "$filename"
}

# Change into the state directory (and create it if it doesn't exist)
if [ ! -d "$STATE_DIR" ]; then
  mkdir -p $STATE_DIR
fi
cd "$STATE_DIR"

# Verify that docker is reachable
$DOCKER version 1>/dev/null

# List all currently existing containers
$DOCKER ps -a -q --no-trunc | sort | uniq > containers.all

# List running containers
$DOCKER ps -q --no-trunc | sort | uniq > containers.running
container_log "Container running" containers.running

# compute ids of container images to exclude from GC
compute_exclude_ids

# compute ids of containers to exclude from GC
compute_exclude_container_ids

# List containers that are not running
comm -23 containers.all containers.running > containers.exited

if [[ $EXCLUDE_DEAD -gt 0 ]]; then
  echo "Excluding dead containers"
  # List dead containers
  $DOCKER ps -q -a -f status=dead | sort | uniq > containers.dead
  comm -23 containers.exited containers.dead > containers.exited.tmp
  cp containers.exited.tmp containers.exited
fi

container_log "Container not running" containers.exited

# Find exited containers that finished at least GRACE_PERIOD_SECONDS ago
> containers.reap.tmp
while read line
do
  EXITED=$(${DOCKER} inspect -f "{{json .State.FinishedAt}}" ${line})
  ELAPSED=$(elapsed_time $EXITED)
  if [[ $ELAPSED -gt $GRACE_PERIOD_SECONDS ]]; then
    echo $line >> containers.reap.tmp
  fi
done < containers.exited

# List containers that we will remove and exclude ids.
sort containers.reap.tmp | uniq | grep -v -f $EXCLUDE_CONTAINER_IDS_FILE > containers.reap || true

# List containers that we will keep.
comm -23 containers.all containers.reap > containers.keep

# List images used by containers that we keep.
xargs -n 1 $DOCKER inspect -f '{{.Image}}' < containers.keep 2>/dev/null |
sort | uniq > images.used

# List images to reap; images that existed last run and are not in use.
echo -n "" > images.all
$DOCKER images | while read line
do
  awk '{print $1};'
done | sort | uniq | while read line
do
  $DOCKER images --no-trunc --format "{{.ID}} {{.CreatedAt}}" $line \
    | sort -k 2 -r \
    | tail -n +$((MINIMUM_IMAGES_TO_SAVE+1)) \
    | cut -f 1 -d " " \
    | uniq >> images.all
done

# Add dangling images to list.
$DOCKER images --no-trunc --format "{{.ID}}" --filter dangling=true >> images.all

# Find images that are created at least GRACE_PERIOD_SECONDS ago
> images.reap.tmp
sort images.all | uniq | while read line
do
  # the `docker inspect` command might fail sometimes due to issues like
  # https://github.com/moby/moby/issues/35747 - don't let that abort this script
  set +o errexit
  CREATED=$(${DOCKER} inspect -f "{{.Created}}" ${line})
  retval=$?
  set -o errexit
  if [ $retval -eq 0 ]; then
    ELAPSED=$(elapsed_time $CREATED)
    if [[ $ELAPSED -gt $GRACE_PERIOD_SECONDS ]]; then
      echo $line >> images.reap.tmp
    fi
  fi
done
comm -23 images.reap.tmp images.used | grep -E -v -f $EXCLUDE_IDS_FILE > images.reap || true

# Use -f flag on docker rm command; forces removal of images that are in Dead
# status or give errors when removing.
FORCE_CONTAINER_FLAG=""
if [[ $FORCE_CONTAINER_REMOVAL -gt 0 ]]; then
  FORCE_CONTAINER_FLAG="-f"
fi

# Remove associated volume, so that we won't create new orphan volumes.
if [[ $REMOVE_ASSOCIATED_VOLUME -gt 0 ]]; then
  if [[ -z $FORCE_CONTAINER_FLAG ]]; then
    FORCE_CONTAINER_FLAG="-v"
  else
    FORCE_CONTAINER_FLAG=$FORCE_CONTAINER_FLAG"v"
  fi
fi
# Reap containers.
if [[ $DRY_RUN -gt 0 ]]; then
  container_log "The following container would have been removed" containers.reap
else
  container_log "Removing containers" containers.reap
  xargs -n 1 $DOCKER rm $FORCE_CONTAINER_FLAG --volumes=true < containers.reap &>/dev/null || true
fi

# Use -f flag on docker rmi command; forces removal of images that have multiple tags
FORCE_IMAGE_FLAG=""
if [[ $FORCE_IMAGE_REMOVAL -gt 0 ]]; then
  FORCE_IMAGE_FLAG="-f"
fi

# Reap images.
if [[ $DRY_RUN -gt 0 ]]; then
  image_log "The following image would have been removed" images.reap
else
  image_log "Removing image" images.reap
  xargs -n 1 $DOCKER rmi $FORCE_IMAGE_FLAG < images.reap &>/dev/null || true
fi

if [[ $REMOVE_VOLUMES -gt 0 ]]; then
  set +e
  $DOCKER volume ls --filter "dangling=true" -q &> /dev/null
  VOLUMES=$?
  set -e
  # If docker volume ls fails, then is probably not supported by either client or server
  if [ ! -f "$EXCLUDE_VOLUMES_IDS_FILE" ]; then
    EXCLUDE_VOLUMES_IDS_FILE=/dev/null
  fi

  if [[ $VOLUMES -gt 0 ]]; then
    set +e
    VERSION=$($DOCKER version --format="Client: {{.Client.Version}} Server: {{.Server.Version}}" 2&>/dev/null)
    FORMAT=$?
    set -e
    if [[ $FORMAT -gt 0 ]]; then
      log "Removing volumes is not supported for Docker < 1.9.0"
    else
      MESSAGE="Removing volumes is not supported for for docker version "$($DOCKER version --format="Client: {{.Client.Version}} Server: {{.Server.Version}}") &
      log "$MESSAGE"
    fi
  else
    if [[ -z "$VOLUME_DELETE_ONLY_DRIVER" ]]; then
      $DOCKER volume ls --filter "dangling=true" -q | grep -v -f $EXCLUDE_VOLUMES_IDS_FILE > volumes.reap || true
    else
      $DOCKER volume ls --filter "dangling=true" --filter "driver=$VOLUME_DELETE_ONLY_DRIVER" -q | grep -v -f $EXCLUDE_VOLUMES_IDS_FILE > volumes.reap || true
    fi

    if [[ $DRY_RUN -gt 0 ]]; then
      volumes_log "The following volume would have been removed" volumes.reap
    else
      volumes_log "Removing volume" volumes.reap
      xargs -n 1 $DOCKER volume rm < volumes.reap &>/dev/null || true
    fi
  fi
fi
